﻿namespace MSSqlSchemaDoc.Core
{
    using System;
    using System.Collections.ObjectModel;
    using System.IO;
    using System.Xml;
    using System.Xml.XPath;

    using MSSqlSchemaDoc.Core.DataStructures;

    /// <summary>
    /// Provides a base implementation of an <see cref="IScriptingManager">IScriptingManager</see> object.
    /// You should NOT code against this class, but rather against the interface!
    /// </summary>
    public abstract class ScriptingManagerBase : IScriptingManager
    {
        /// <summary>
        /// Stores the scripted objects so that they can be returned to the calling code.
        /// </summary>
        private Collection<IDataStructureObject> currentScriptingCollection;

        /// <summary>
        /// Stores the scripting destination for the current scripting operation.
        /// </summary>
        private ScriptingDestination currentScriptingDestination;

        /// <summary>
        /// The folder where objects are saved when they are scripted "to file".
        /// </summary>
        private DirectoryInfo scriptingDestinationFolder;

        /// <summary>
        /// The destination folder for the documentation objects.
        /// </summary>
        private DirectoryInfo documentationDestinationFolder;

        /// <summary>
        /// The common settings to use when writing xml files.
        /// </summary>
        private XmlWriterSettings xmlWriterSettings;

        /// <summary>
        /// Fires when a new object type is being scripted.
        /// </summary>
        public event EventHandler<SimpleEventArgs<string>> BeginScriptingNewObjectType;

        /// <summary>
        /// Fires when an object type scripting has completed.
        /// </summary>
        public event EventHandler<SimpleEventArgs<string>> CompleteScriptingNewObjectType;

        /// <summary>
        /// Fires when a new object is being scripted.
        /// </summary>
        public event EventHandler<SimpleEventArgs<string>> BeginScriptingObject;

        /// <summary>
        /// Fires when scripting of an object completes.
        /// </summary>
        public event EventHandler<SimpleEventArgs<string>> CompleteScriptingObject;

        /// <summary>
        /// Is used internally to indicate what to do with a newly scripted object.
        /// </summary>
        private enum ScriptingDestination
        {
            /// <summary>
            /// The scripted objects should be added to a collection which is returned to the calling code.
            /// </summary>
            Collection,

            /// <summary>
            /// The scripted objects are written to disk.
            /// </summary>
            File,

            /// <summary>
            /// A callback function is invoked in the calling code whenever an object is scripted.
            /// </summary>
            Callback
        }

        /// <summary>
        /// Gets or sets a value indicating whether the generated Xml should be validated against the Xml Schemas.
        /// </summary>
        public bool ValidateXml { get; set; }

        /// <summary>
        /// Gets or sets a value indicating whether to also script the documentation objects.
        /// </summary>
        public bool IncludeDocumentationObjects { get; set; }

        /// <summary>
        /// Gets or sets the source folder for documentation.
        /// The documentation in the existing files will be carried over to the new objects.
        /// </summary>
        public DirectoryInfo DocumentationSourceFolder { get; set; }

        /// <summary>
        /// Gets or sets a value indicating whether a sub directory should be created for each object type, when scripting to file.
        /// </summary>
        protected bool CreateSubDirectories { get; set; }

        /// <summary>
        /// Script a connection and save the results in files.
        /// In addition to the script being saved in a file, it is also possible to handle
        /// the "CompleteScriptingObject" event and get a reference to each of the objects as they are scripted.
        /// </summary>
        /// <param name="connectionString">The connection string for the database to script.</param>
        /// <param name="scriptingFolder">The directory in which to place the files.</param>
        /// <param name="createSubfolders">Indicates whether to create a folder for each type of object.</param>
        /// <param name="documentationDestinationFolder">The destination folder for the documentation objects.</param>
        public void ScriptToFile(
            string connectionString,
            DirectoryInfo scriptingFolder,
            bool createSubfolders,
            DirectoryInfo documentationDestinationFolder)
        {
            this.ScriptToFile(connectionString, scriptingFolder, createSubfolders, documentationDestinationFolder, null, null, null, null);
        }

        /// <summary>
        /// The entry point into the file scripting process with filters applied.
        /// </summary>
        /// <param name="connectionString">The connection string for the database to script.</param>
        /// <param name="scriptingFolder">The directory in which to place the files.</param>
        /// <param name="createSubfolders">Indicates whether to create a folder for each type of object.</param>
        /// <param name="documentationDestinationFolder">The destination folder for the documentation objects.</param>
        /// <param name="typesToInclude">
        /// A specific list of object types to include in the scripting process.
        /// Pass an empty array or null to include all object types.
        /// </param>
        /// <param name="typesToExclude">
        /// A list of object types to exclude from the scripting specifically.
        /// Overrides the included object types if there is a conflict.
        /// Pass empty array or null not to for nothing to be excluded.
        /// </param>
        /// <param name="objectsToInclude">
        /// A specific list of objects to include in the scripting process.
        /// Pass an empty array or null to include all objects.
        /// </param>
        /// <param name="objectsToExclude">
        /// A list of object types to exclude from the scripting specifically.
        /// Overrides the included objects if there is a conflict.
        /// Pass empty array or null not to for nothing to be excluded.
        /// </param>
        public void ScriptToFile(
            string connectionString,
            DirectoryInfo scriptingFolder,
            bool createSubfolders,
            DirectoryInfo documentationDestinationFolder,
            string[] typesToInclude,
            string[] typesToExclude,
            string[] objectsToInclude,
            string[] objectsToExclude)
        {
            this.currentScriptingDestination = ScriptingDestination.File;
            this.scriptingDestinationFolder = scriptingFolder;
            this.CreateSubDirectories = createSubfolders;
            this.documentationDestinationFolder = documentationDestinationFolder;

            this.xmlWriterSettings = new XmlWriterSettings();
            this.xmlWriterSettings.Indent = true;
            this.xmlWriterSettings.NewLineOnAttributes = false;
            this.xmlWriterSettings.NewLineHandling = NewLineHandling.Entitize;
            this.xmlWriterSettings.ConformanceLevel = ConformanceLevel.Document;
            this.xmlWriterSettings.OmitXmlDeclaration = false;

            this.Script(connectionString, typesToInclude, typesToExclude, objectsToInclude, objectsToExclude);
        }

        /// <summary>
        /// The entry point into the collection scripting process.
        /// </summary>
        /// <param name="connectionString">The connection string for the database to script.</param>
        /// <returns>The collection of objects.</returns>
        public Collection<IDataStructureObject> ScriptToCollection(string connectionString)
        {
            return this.ScriptToCollection(connectionString, null, null, null, null);
        }

        /// <summary>
        /// The entry point into the collection scripting process with filters applied.
        /// </summary>
        /// <param name="connectionString">The connection string for the database to script.</param>
        /// <param name="typesToInclude">
        /// A specific list of object types to include in the scripting process.
        /// Pass an empty array or null to include all object types.
        /// </param>
        /// <param name="typesToExclude">
        /// A list of object types to exclude from the scripting specifically.
        /// Overrides the included object types if there is a conflict.
        /// Pass empty array or null not to for nothing to be excluded.
        /// </param>
        /// <param name="objectsToInclude">
        /// A specific list of objects to include in the scripting process.
        /// Pass an empty array or null to include all objects.
        /// </param>
        /// <param name="objectsToExclude">
        /// A list of object types to exclude from the scripting specifically.
        /// Overrides the included objects if there is a conflict.
        /// Pass empty array or null not to for nothing to be excluded.
        /// </param>
        /// <returns>The collection of filtered objects.</returns>
        public Collection<IDataStructureObject> ScriptToCollection(
            string connectionString,
            string[] typesToInclude,
            string[] typesToExclude,
            string[] objectsToInclude,
            string[] objectsToExclude)
        {
            this.currentScriptingDestination = ScriptingDestination.Collection;
            this.currentScriptingCollection = new Collection<IDataStructureObject>();

            this.Script(connectionString, typesToInclude, typesToExclude, objectsToInclude, objectsToExclude);
            return this.currentScriptingCollection;
        }

        /// <summary>
        /// Script a connection, but don't returned the objects - except through the raised event.
        /// </summary>
        /// <param name="connectionString">The connection string for the database to script.</param>
        public void ScriptToCallback(string connectionString)
        {
            this.ScriptToCallback(connectionString, null, null, null, null);
        }

        /// <summary>
        /// Script a connection, but don't returned the objects - except through the raised event.
        /// Apply certain filters to the objects and/or object types.
        /// </summary>
        /// <param name="connectionString">The connection string for the database to script.</param>
        /// <param name="typesToInclude">
        /// A specific list of object types to include in the scripting process.
        /// Pass an empty array or null to include all object types.
        /// </param>
        /// <param name="typesToExclude">
        /// A list of object types to exclude from the scripting specifically.
        /// Overrides the included object types if there is a conflict.
        /// Pass empty array or null not to for nothing to be excluded.
        /// </param>
        /// <param name="objectsToInclude">
        /// A specific list of objects to include in the scripting process.
        /// Pass an empty array or null to include all objects.
        /// </param>
        /// <param name="objectsToExclude">
        /// A list of object types to exclude from the scripting specifically.
        /// Overrides the included objects if there is a conflict.
        /// Pass empty array or null not to for nothing to be excluded.
        /// </param>
        public void ScriptToCallback(
            string connectionString,
            string[] typesToInclude,
            string[] typesToExclude,
            string[] objectsToInclude,
            string[] objectsToExclude)
        {
            this.currentScriptingDestination = ScriptingDestination.Callback;
            this.Script(connectionString, typesToInclude, typesToExclude, objectsToInclude, objectsToExclude);
        }

        /// <summary>
        /// Checks the filters and indicates whether to include / exlude the object type from scripting.
        /// </summary>
        /// <param name="objectType">The type to check.</param>
        /// <param name="typesToInclude">The types to include.</param>
        /// <param name="typesToExclude">The types to exlude.</param>
        /// <returns>True, if the object type should be included.</returns>
        protected static bool IncludeObjectTypeInScripting(string objectType, string[] typesToInclude, string[] typesToExclude)
        {
            bool include = false;

            // If null or empty - include all
            if (typesToInclude == null || typesToInclude.Length == 0)
            {
                include = true;
            }
            else
            {
                // Check whether it should explicitly be included
                foreach (string type in typesToInclude)
                {
                    if (type == objectType)
                    {
                        include = true;
                    }
                }
            }

            // Only check the exclusions if the object type is being included so far
            if (include)
            {
                if (typesToExclude != null && typesToExclude.Length > 0)
                {
                    foreach (string type in typesToExclude)
                    {
                        if (type == objectType)
                        {
                            include = false;
                        }
                    }
                }
            }

            return include;
        }

        /// <summary>
        /// Calculates the path and file name of the destination file.
        /// </summary>
        /// <param name="folder">The root folder where the object should be saved.</param>
        /// <param name="filename">The file name to be used.</param>
        /// <param name="objectType">The name of the object type which is used to create the sub directory (if required).</param>
        /// <param name="createSubDirectories">A value indicating whether to create sub directories per object type.</param>
        /// <returns>An instance of an object carrying the path file name information.</returns>
        protected static FileInfo GetFileInfo(DirectoryInfo folder, string filename, string objectType, bool createSubDirectories)
        {
            DirectoryInfo subFolder;
            if (createSubDirectories)
            {
                subFolder = new DirectoryInfo(Path.Combine(folder.FullName, objectType));
            }
            else
            {
                subFolder = new DirectoryInfo(folder.FullName);
            }

            if (!subFolder.Exists)
            {
                subFolder.Create();
            }

            string fileFullName = Path.Combine(subFolder.FullName, filename + ".xml");
            return new FileInfo(fileFullName);
        }

        /// <summary>
        /// This method should be implemented by all derived classes.
        /// This is where the main scripting happens.
        /// </summary>
        /// <param name="connectionString">The connection string to the data source to script.</param>
        /// <param name="typesToInclude">The object types to include.</param>
        /// <param name="typesToExclude">The object types to exclude.</param>
        /// <param name="objectsToInclude">The objects to include.</param>
        /// <param name="objectsToExclude">The objects to exclude.</param>
        protected abstract void Script(
            string connectionString,
            string[] typesToInclude,
            string[] typesToExclude,
            string[] objectsToInclude,
            string[] objectsToExclude);

        /// <summary>
        /// Fires the StartScriptingNewObjectType event.
        /// </summary>
        /// <param name="name">The type of objects that are being scripted.</param>
        protected void OnBeginScriptingNewObjectType(string name)
        {
            if (this.BeginScriptingNewObjectType != null)
            {
                this.BeginScriptingNewObjectType(null, new SimpleEventArgs<string>(name));
            }
        }

        /// <summary>
        /// Fires the CompleteScriptingNewObjectType event.
        /// </summary>
        /// <param name="name">The type of object that has been scripted.</param>
        protected void OnCompleteScriptingNewObjectType(string name)
        {
            if (this.CompleteScriptingNewObjectType != null)
            {
                this.CompleteScriptingNewObjectType(null, new SimpleEventArgs<string>(name));
            }
        }

        /// <summary>
        /// Fires the OnBeginScriptingObject event.
        /// </summary>
        /// <param name="name">The name of the object being scripted.</param>
        protected void OnBeginScriptingObject(string name)
        {
            if (this.BeginScriptingObject != null)
            {
                this.BeginScriptingObject(null, new SimpleEventArgs<string>(name));
            }
        }

        /// <summary>
        /// Fires the OnCompleteScriptingObject event.
        /// </summary>
        /// <param name="scriptedObject">The object that was created during the scripting process.</param>
        /// <param name="name">The name of the object that was just created.</param>
        protected virtual void OnCompleteScriptingObject(IDataStructureObject scriptedObject, string name)
        {
            this.HandleObjectScripted(scriptedObject);

            if (this.CompleteScriptingObject != null)
            {
                this.CompleteScriptingObject(scriptedObject, new SimpleEventArgs<string>(name));
            }
        }

        /// <summary>
        /// Handles errors resulting from validating Xml.
        /// </summary>
        /// <param name="sender">The object being validated?</param>
        /// <param name="args">The validation event arguments.</param>
        private static void XmlValidationHandler(object sender, System.Xml.Schema.ValidationEventArgs args)
        {
            throw new Exception(args.Message);
        }

        /// <summary>
        /// Handles the scripted object. Moving it to the desired destination.
        /// </summary>
        /// <param name="scriptedObject">The object that was scripted.</param>
        private void HandleObjectScripted(IDataStructureObject scriptedObject)
        {
            switch (this.currentScriptingDestination)
            {
                case ScriptingDestination.File:
                    this.SaveObjectToFile(scriptedObject);
                    break;
                case ScriptingDestination.Collection:
                    this.currentScriptingCollection.Add(scriptedObject);
                    break;
            }
        }

        /// <summary>
        /// Saves an object to file as xml.
        /// </summary>
        /// <param name="objectToSave">The object to save.</param>
        private void SaveObjectToFile(IDataStructureObject objectToSave)
        {
            XmlDocument xml = XmlSerializer.SerializeDataStructureObject(objectToSave);

            IDocumentationObject documentationObject;
            XmlDocument xmlDocumentation;
            if (this.IncludeDocumentationObjects && objectToSave.Documentation != null)
            {
                documentationObject = objectToSave.Documentation;
                xmlDocumentation = XmlSerializer.SerializeDataStructureObject(documentationObject);
            }
            else
            {
                documentationObject = null;
                xmlDocumentation = null;
            }

            if (this.ValidateXml)
            {
                xml.Schemas.Add(XmlSerializer.ConstructSchemaSet());
                xml.Validate(XmlValidationHandler);

                if (xmlDocumentation != null)
                {
                    xmlDocumentation.Schemas.Add(XmlSerializer.ConstructSchemaSet());
                    xmlDocumentation.Validate(XmlValidationHandler);
                }
            }

            // Open the file and write the contents
            using (XmlWriter w = XmlWriter.Create(
                GetFileInfo(this.scriptingDestinationFolder, objectToSave.ToString(), objectToSave.ObjectType.ToString(), this.CreateSubDirectories).FullName,
                this.xmlWriterSettings))
            {
                xml.WriteTo(w);
            }

            if (documentationObject != null)
            {
                using (XmlWriter w = XmlWriter.Create(
                    GetFileInfo(this.documentationDestinationFolder, documentationObject.ToString(), documentationObject.ObjectType.ToString(), this.CreateSubDirectories).FullName,
                    this.xmlWriterSettings))
                {
                    xmlDocumentation.WriteTo(w);
                }
            }
        }
    }
}
